// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:fixnum/fixnum.dart';
import 'package:meta/meta.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';

import 'context.dart';
import 'git.dart';
import 'globals.dart';
import 'proto/conductor_state.pb.dart' as pb;
import 'proto/conductor_state.pbenum.dart';
import 'repository.dart';
import 'state.dart' as state_import;
import 'stdio.dart';
import 'version.dart';

const String kCandidateOption = 'candidate-branch';
const String kDartRevisionOption = 'dart-revision';
const String kEngineCherrypicksOption = 'engine-cherrypicks';
const String kEngineUpstreamOption = 'engine-upstream';
const String kFrameworkCherrypicksOption = 'framework-cherrypicks';
const String kFrameworkMirrorOption = 'framework-mirror';
const String kFrameworkUpstreamOption = 'framework-upstream';
const String kEngineMirrorOption = 'engine-mirror';
const String kReleaseOption = 'release-channel';
const String kStateOption = 'state-file';
const String kVersionOverrideOption = 'version-override';
const String kGithubUsernameOption = 'github-username';

/// Command to print the status of the current Flutter release.
class StartCommand extends Command<void> {
  StartCommand({
    required this.checkouts,
    required this.conductorVersion,
  })  : platform = checkouts.platform,
        processManager = checkouts.processManager,
        fileSystem = checkouts.fileSystem,
        stdio = checkouts.stdio {
    final String defaultPath = state_import.defaultStateFilePath(platform);
    argParser.addOption(
      kCandidateOption,
      help: 'The candidate branch the release will be based on.',
    );
    argParser.addOption(
      kReleaseOption,
      help: 'The target release channel for the release.',
      allowed: kBaseReleaseChannels,
    );
    argParser.addOption(
      kFrameworkUpstreamOption,
      defaultsTo: FrameworkRepository.defaultUpstream,
      help:
          'Configurable Framework repo upstream remote. Primarily for testing.',
      hide: true,
    );
    argParser.addOption(
      kEngineUpstreamOption,
      defaultsTo: EngineRepository.defaultUpstream,
      help: 'Configurable Engine repo upstream remote. Primarily for testing.',
      hide: true,
    );
    argParser.addOption(
      kStateOption,
      defaultsTo: defaultPath,
      help: 'Path to persistent state file. Defaults to $defaultPath',
    );
    argParser.addMultiOption(
      kEngineCherrypicksOption,
      help: 'Engine cherrypick hashes to be applied.',
      defaultsTo: <String>[],
    );
    argParser.addMultiOption(
      kFrameworkCherrypicksOption,
      help: 'Framework cherrypick hashes to be applied.',
      defaultsTo: <String>[],
    );
    argParser.addOption(
      kDartRevisionOption,
      help: 'New Dart revision to cherrypick.',
    );
    argParser.addFlag(
      kForceFlag,
      abbr: 'f',
      help: 'Override all validations of the command line inputs.',
    );
    argParser.addOption(
      kVersionOverrideOption,
      help: 'Explicitly set the desired version. This should only be used if '
          'the version computed by the tool is not correct.',
    );
    argParser.addOption(
      kGithubUsernameOption,
      help: 'Github username',
    );
  }

  final Checkouts checkouts;

  final String conductorVersion;
  final FileSystem fileSystem;
  final Platform platform;
  final ProcessManager processManager;
  final Stdio stdio;

  @override
  String get name => 'start';

  @override
  String get description => 'Initialize a new Flutter release.';

  @override
  Future<void> run() async {
    final ArgResults argumentResults = argResults!;
    if (!platform.isMacOS && !platform.isLinux) {
      throw ConductorException(
        'Error! This tool is only supported on macOS and Linux',
      );
    }

    final String frameworkUpstream = getValueFromEnvOrArgs(
      kFrameworkUpstreamOption,
      argumentResults,
      platform.environment,
    )!;
    final String githubUsername = getValueFromEnvOrArgs(
      kGithubUsernameOption,
      argumentResults,
      platform.environment,
    )!;
    final String frameworkMirror =
        'git@github.com:$githubUsername/flutter.git';
    final String engineUpstream = getValueFromEnvOrArgs(
      kEngineUpstreamOption,
      argumentResults,
      platform.environment,
    )!;
    final String engineMirror = 'git@github.com:$githubUsername/engine.git';
    final String candidateBranch = getValueFromEnvOrArgs(
      kCandidateOption,
      argumentResults,
      platform.environment,
    )!;
    final String releaseChannel = getValueFromEnvOrArgs(
      kReleaseOption,
      argumentResults,
      platform.environment,
    )!;
    final List<String> frameworkCherrypickRevisions = getValuesFromEnvOrArgs(
      kFrameworkCherrypicksOption,
      argumentResults,
      platform.environment,
    );
    final List<String> engineCherrypickRevisions = getValuesFromEnvOrArgs(
      kEngineCherrypicksOption,
      argumentResults,
      platform.environment,
    );
    final String? dartRevision = getValueFromEnvOrArgs(
      kDartRevisionOption,
      argumentResults,
      platform.environment,
      allowNull: true,
    );
    final bool force = getBoolFromEnvOrArgs(
      kForceFlag,
      argumentResults,
      platform.environment,
    );
    final File stateFile = checkouts.fileSystem.file(
      getValueFromEnvOrArgs(
          kStateOption, argumentResults, platform.environment),
    );
    final String? versionOverrideString = getValueFromEnvOrArgs(
      kVersionOverrideOption,
      argumentResults,
      platform.environment,
      allowNull: true,
    );
    Version? versionOverride;
    if (versionOverrideString != null) {
      versionOverride = Version.fromString(versionOverrideString);
    }

    final StartContext context = StartContext(
      candidateBranch: candidateBranch,
      checkouts: checkouts,
      dartRevision: dartRevision,
      engineCherrypickRevisions: engineCherrypickRevisions,
      engineMirror: engineMirror,
      engineUpstream: engineUpstream,
      conductorVersion: conductorVersion,
      frameworkCherrypickRevisions: frameworkCherrypickRevisions,
      frameworkMirror: frameworkMirror,
      frameworkUpstream: frameworkUpstream,
      processManager: processManager,
      releaseChannel: releaseChannel,
      stateFile: stateFile,
      force: force,
      versionOverride: versionOverride,
      githubUsername: githubUsername,
    );
    return context.run();
  }
}

/// Context for starting a new release.
///
/// This is a frontend-agnostic implementation.
class StartContext extends Context {
  StartContext({
    required this.candidateBranch,
    required this.dartRevision,
    required this.engineCherrypickRevisions,
    required this.engineMirror,
    required this.engineUpstream,
    required this.frameworkCherrypickRevisions,
    required this.frameworkMirror,
    required this.frameworkUpstream,
    required this.conductorVersion,
    required this.processManager,
    required this.releaseChannel,
    required this.githubUsername,
    required super.checkouts,
    required super.stateFile,
    this.force = false,
    this.versionOverride,
  })  : git = Git(processManager),
        engine = EngineRepository(
          checkouts,
          initialRef: 'upstream/$candidateBranch',
          upstreamRemote: Remote(
            name: RemoteName.upstream,
            url: engineUpstream,
          ),
          mirrorRemote: Remote(
            name: RemoteName.mirror,
            url: engineMirror,
          ),
        ),
        framework = FrameworkRepository(
          checkouts,
          initialRef: 'upstream/$candidateBranch',
          upstreamRemote: Remote(
            name: RemoteName.upstream,
            url: frameworkUpstream,
          ),
          mirrorRemote: Remote(
            name: RemoteName.mirror,
            url: frameworkMirror,
          ),
        );

  final String candidateBranch;
  final String? dartRevision;
  final List<String> engineCherrypickRevisions;
  final String engineMirror;
  final String engineUpstream;
  final List<String> frameworkCherrypickRevisions;
  final String frameworkMirror;
  final String frameworkUpstream;
  final String conductorVersion;
  final Git git;
  final ProcessManager processManager;
  final String releaseChannel;
  final Version? versionOverride;
  final String githubUsername;

  /// If validations should be overridden.
  final bool force;

  final EngineRepository engine;
  final FrameworkRepository framework;

  /// Determine which part of the version to increment in the next release.
  ///
  /// If [atBranchPoint] is true, then this is a [ReleaseType.BETA_INITIAL].
  @visibleForTesting
  ReleaseType computeReleaseType(Version lastVersion, bool atBranchPoint) {
    if (atBranchPoint) {
      return ReleaseType.BETA_INITIAL;
    }

    if (releaseChannel == 'stable') {
      if (lastVersion.type == VersionType.stable) {
        return ReleaseType.STABLE_HOTFIX;
      } else {
        return ReleaseType.STABLE_INITIAL;
      }
    }

    return ReleaseType.BETA_HOTFIX;
  }

  Future<void> run() async {
    if (stateFile.existsSync()) {
      throw ConductorException(
          'Error! A persistent state file already found at ${stateFile.path}.\n\n'
          'Run `conductor clean` to cancel a previous release.');
    }
    if (!releaseCandidateBranchRegex.hasMatch(candidateBranch)) {
      throw ConductorException(
        'Invalid release candidate branch "$candidateBranch". Text should '
        'match the regex pattern /${releaseCandidateBranchRegex.pattern}/.',
      );
    }

    final Int64 unixDate = Int64(DateTime.now().millisecondsSinceEpoch);
    final pb.ConductorState state = pb.ConductorState();

    state.releaseChannel = releaseChannel;
    state.createdDate = unixDate;
    state.lastUpdatedDate = unixDate;

    // Create a new branch so that we don't accidentally push to upstream
    // candidateBranch.
    final String workingBranchName = 'cherrypicks-$candidateBranch';
    await engine.newBranch(workingBranchName);

    if (dartRevision != null && dartRevision!.isNotEmpty) {
      await engine.updateDartRevision(dartRevision!);
      await engine.commit('Update Dart SDK to $dartRevision', addFirst: true);
    }
    final List<pb.Cherrypick> engineCherrypicks = (await _sortCherrypicks(
      repository: engine,
      cherrypicks: engineCherrypickRevisions,
      upstreamRef: EngineRepository.defaultBranch,
      releaseRef: candidateBranch,
    ))
        .map((String revision) => pb.Cherrypick(
              trunkRevision: revision,
              state: pb.CherrypickState.PENDING,
            ))
        .toList();

    for (final pb.Cherrypick cherrypick in engineCherrypicks) {
      final String revision = cherrypick.trunkRevision;
      final bool success = await engine.canCherryPick(revision);
      stdio.printTrace(
        'Attempt to cherrypick $revision ${success ? 'succeeded' : 'failed'}',
      );
      if (success) {
        await engine.cherryPick(revision);
        cherrypick.state = pb.CherrypickState.COMPLETED;
      } else {
        cherrypick.state = pb.CherrypickState.PENDING_WITH_CONFLICT;
      }
    }
    final String engineHead = await engine.reverseParse('HEAD');
    state.engine = pb.Repository(
      candidateBranch: candidateBranch,
      workingBranch: workingBranchName,
      startingGitHead: engineHead,
      currentGitHead: engineHead,
      checkoutPath: (await engine.checkoutDirectory).path,
      cherrypicks: engineCherrypicks,
      dartRevision: dartRevision,
      upstream: pb.Remote(name: 'upstream', url: engine.upstreamRemote.url),
      mirror: pb.Remote(name: 'mirror', url: engine.mirrorRemote!.url),
    );

    await framework.newBranch(workingBranchName);
    final List<pb.Cherrypick> frameworkCherrypicks = (await _sortCherrypicks(
      repository: framework,
      cherrypicks: frameworkCherrypickRevisions,
      upstreamRef: FrameworkRepository.defaultBranch,
      releaseRef: candidateBranch,
    ))
        .map((String revision) => pb.Cherrypick(
              trunkRevision: revision,
              state: pb.CherrypickState.PENDING,
            ))
        .toList();

    for (final pb.Cherrypick cherrypick in frameworkCherrypicks) {
      final String revision = cherrypick.trunkRevision;
      final bool success = await framework.canCherryPick(revision);
      stdio.printTrace(
        'Attempt to cherrypick $cherrypick ${success ? 'succeeded' : 'failed'}',
      );
      if (success) {
        await framework.cherryPick(revision);
        cherrypick.state = pb.CherrypickState.COMPLETED;
      } else {
        cherrypick.state = pb.CherrypickState.PENDING_WITH_CONFLICT;
      }
    }

    // Get framework version
    final Version lastVersion = Version.fromString(await framework.getFullTag(
      framework.upstreamRemote.name,
      candidateBranch,
      exact: false,
    ));

    final String frameworkHead = await framework.reverseParse('HEAD');
    final String branchPoint = await framework.branchPoint(
      '${framework.upstreamRemote.name}/$candidateBranch',
      '${framework.upstreamRemote.name}/${FrameworkRepository.defaultBranch}',
    );
    final bool atBranchPoint = branchPoint == frameworkHead;

    final ReleaseType releaseType =
        computeReleaseType(lastVersion, atBranchPoint);
    state.releaseType = releaseType;

    try {
      lastVersion.ensureValid(candidateBranch, releaseType);
    } on ConductorException catch (e) {
      // Let the user know, but resume execution
      stdio.printError(e.message);
    }

    Version nextVersion;
    if (versionOverride != null) {
      nextVersion = versionOverride!;
    } else {
      nextVersion = calculateNextVersion(lastVersion, releaseType);
      nextVersion = await ensureBranchPointTagged(
        branchPoint: branchPoint,
        requestedVersion: nextVersion,
        framework: framework,
      );
    }

    state.releaseVersion = nextVersion.toString();

    state.framework = pb.Repository(
      candidateBranch: candidateBranch,
      workingBranch: workingBranchName,
      startingGitHead: frameworkHead,
      currentGitHead: frameworkHead,
      checkoutPath: (await framework.checkoutDirectory).path,
      cherrypicks: frameworkCherrypicks,
      upstream: pb.Remote(name: 'upstream', url: framework.upstreamRemote.url),
      mirror: pb.Remote(name: 'mirror', url: framework.mirrorRemote!.url),
    );

    state.currentPhase = ReleasePhase.APPLY_ENGINE_CHERRYPICKS;

    state.conductorVersion = conductorVersion;

    stdio.printTrace('Writing state to file ${stateFile.path}...');

    updateState(state, stdio.logs);

    stdio.printStatus(state_import.presentState(state));
  }

  /// Determine this release's version number from the [lastVersion] and the [incrementLetter].
  Version calculateNextVersion(Version lastVersion, ReleaseType releaseType) {
    late final Version nextVersion;
    switch (releaseType) {
      case ReleaseType.STABLE_INITIAL:
        nextVersion = Version(
          x: lastVersion.x,
          y: lastVersion.y,
          z: 0,
          type: VersionType.stable,
        );
        break;
      case ReleaseType.STABLE_HOTFIX:
        nextVersion = Version.increment(lastVersion, 'z');
        break;
      case ReleaseType.BETA_INITIAL:
        nextVersion = Version.fromCandidateBranch(candidateBranch);
        break;
      case ReleaseType.BETA_HOTFIX:
        nextVersion = Version.increment(lastVersion, 'n');
        break;
    }
    return nextVersion;
  }

  /// Ensures the branch point [candidateBranch] and `master` has a version tag.
  ///
  /// This is necessary for version reporting for users on the `master` channel
  /// to be correct.
  Future<Version> ensureBranchPointTagged({
    required Version requestedVersion,
    required String branchPoint,
    required FrameworkRepository framework,
  }) async {
    if (await framework.isCommitTagged(branchPoint)) {
      // The branch point is tagged, no work to be done
      return requestedVersion;
    }
    if (requestedVersion.n != 0) {
      stdio.printError(
        'Tried to tag the branch point, however the target version is '
        '$requestedVersion, which does not have n == 0!',
      );
      return requestedVersion;
    }

    final bool response = await prompt(
      'About to tag the release candidate branch branchpoint of $branchPoint '
      'as $requestedVersion and push it to ${framework.upstreamRemote.url}. '
      'Is this correct?',
    );

    if (!response) {
      throw ConductorException('Aborting command.');
    }

    stdio.printStatus(
        'Applying the tag $requestedVersion at the branch point $branchPoint');

    await framework.tag(
      branchPoint,
      requestedVersion.toString(),
      frameworkUpstream,
    );
    final Version nextVersion = Version.increment(requestedVersion, 'n');
    stdio.printStatus('The actual release will be version $nextVersion.');
    return nextVersion;
  }

  // To minimize merge conflicts, sort the commits by rev-list order.
  Future<List<String>> _sortCherrypicks({
    required Repository repository,
    required List<String> cherrypicks,
    required String upstreamRef,
    required String releaseRef,
  }) async {
    if (cherrypicks.isEmpty) {
      return cherrypicks;
    }

    // Input cherrypick hashes that failed to be parsed by git.
    final List<String> unknownCherrypicks = <String>[];
    // Full 40-char hashes parsed by git.
    final List<String> validatedCherrypicks = <String>[];
    // Final, validated, sorted list of cherrypicks to be applied.
    final List<String> sortedCherrypicks = <String>[];
    for (final String cherrypick in cherrypicks) {
      try {
        final String fullRef = await repository.reverseParse(cherrypick);
        validatedCherrypicks.add(fullRef);
      } on GitException {
        // Catch this exception so that we can validate the rest.
        unknownCherrypicks.add(cherrypick);
      }
    }

    final String branchPoint = await repository.branchPoint(
      '${repository.upstreamRemote.name}/$upstreamRef',
      '${repository.upstreamRemote.name}/$releaseRef',
    );

    // `git rev-list` returns newest first, so reverse this list
    final List<String> upstreamRevlist = (await repository.revList(<String>[
      '--ancestry-path',
      '$branchPoint..$upstreamRef',
    ]))
        .reversed
        .toList();

    stdio.printStatus('upstreamRevList:\n${upstreamRevlist.join('\n')}\n');
    stdio.printStatus(
        'validatedCherrypicks:\n${validatedCherrypicks.join('\n')}\n');
    for (final String upstreamRevision in upstreamRevlist) {
      if (validatedCherrypicks.contains(upstreamRevision)) {
        validatedCherrypicks.remove(upstreamRevision);
        sortedCherrypicks.add(upstreamRevision);
        if (unknownCherrypicks.isEmpty && validatedCherrypicks.isEmpty) {
          return sortedCherrypicks;
        }
      }
    }

    // We were given input cherrypicks that were not present in the upstream
    // rev-list
    stdio.printError(
      'The following ${repository.name} cherrypicks were not found in the '
      'upstream $upstreamRef branch:',
    );
    for (final String cp in <String>[
      ...validatedCherrypicks,
      ...unknownCherrypicks
    ]) {
      stdio.printError('\t$cp');
    }
    throw ConductorException(
      '${validatedCherrypicks.length + unknownCherrypicks.length} unknown cherrypicks provided!',
    );
  }
}
